Coverage Report

Created: 2026-08-07 16:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
D:\a\cssh-rs\cssh-rs\xtask\src\typography.rs
Line
Count
Source
1
//! Typography linter that blocks decorative or "smart" Unicode
2
//! punctuation from sneaking into the repository.
3
//!
4
//! Agents tend to introduce em-dashes, en-dashes, smart quotes,
5
//! ellipsis, arrows, and similar non-ASCII glyphs in comments and
6
//! prose. They look similar to their ASCII equivalents but are not
7
//! what a Windows developer types and not what `cargo fmt` produces.
8
//!
9
//! [`check_typography`] enumerates tracked text files via
10
//! `git ls-files`, scans each for a curated blocklist of code points,
11
//! prints any violations as `path:line:col U+XXXX 'glyph'`, and
12
//! returns an error when at least one violation is found so the
13
//! pre-commit hook and CI both abort.
14
//!
15
//! Performance: the scan runs inside the pre-commit hook, so the
16
//! hot path reads bytes, exits early on pure-ASCII input, and only
17
//! decodes UTF-8 for files that actually contain non-ASCII bytes.
18
19
use std::path::{Path, PathBuf};
20
21
use anyhow::{bail, Context, Result};
22
23
/// File extensions whose contents are scanned.
24
///
25
/// All matching is done in lowercase. Files with no extension are
26
/// scanned only when their path matches [`SCAN_EXTRA_PATHS`].
27
const SCAN_EXTENSIONS: &[&str] = &[
28
    "rs", "md", "toml", "yml", "yaml", "json", "html", "txt", "cfg", "sh", "ps1", "js", "mjs",
29
];
30
31
/// Tracked paths without a recognised extension that should still be
32
/// scanned (shell scripts, hooks, etc.). Compared against the
33
/// `git ls-files` output verbatim (forward slashes).
34
const SCAN_EXTRA_PATHS: &[&str] = &[".githooks/pre-commit"];
35
36
/// Tracked paths that are explicitly excluded from scanning. Used for:
37
///
38
/// - generated artefacts such as `Cargo.lock`,
39
/// - files (such as the `CHANGELOG.md`) that may legitimately preserve
40
///   historical typography from prior releases,
41
/// - templates and workflow snippets whose non-ASCII content is
42
///   intentional and rendered to users (e.g. social-preview titles,
43
///   GitHub Pages footers, PR-comment heredocs).
44
///
45
/// Keep this list short -- the goal is to fix offending content, not
46
/// to allowlist around it. Compared against the `git ls-files` output
47
/// verbatim (forward slashes).
48
const ALLOWED_PATHS: &[&str] = &[
49
    "Cargo.lock",
50
    ".github/workflows/news-fragment-check.yml",
51
    "templates/github-pages-index.html",
52
    "templates/social-preview.html",
53
];
54
55
/// Hard cap on file size accepted by the scanner. Anything larger is
56
/// skipped with a warning -- the repo has nothing close to this size,
57
/// and a pathological large file should not block a commit.
58
const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024;
59
60
/// All side-effecting operations performed by the typography scanner.
61
///
62
/// Implement with mocks in tests to achieve zero filesystem and
63
/// process side-effects.
64
pub trait TypographySystem {
65
    /// Return the list of tracked files reported by `git ls-files`.
66
    ///
67
    /// Paths are returned with forward slashes (the format `git`
68
    /// emits on every platform).
69
    ///
70
    /// # Errors
71
    ///
72
    /// Returns an error if the `git` process cannot be started or
73
    /// exits non-zero.
74
    fn list_tracked_files(&self) -> Result<Vec<String>>;
75
76
    /// Return the size in bytes of the file at `path`.
77
    ///
78
    /// # Errors
79
    ///
80
    /// Returns an error if the file cannot be stat-ed.
81
    fn file_size(&self, path: &Path) -> Result<u64>;
82
83
    /// Read the full contents of the file at `path` as raw bytes.
84
    ///
85
    /// # Errors
86
    ///
87
    /// Returns an error if the file cannot be read.
88
    fn read_file(&self, path: &Path) -> Result<Vec<u8>>;
89
}
90
91
/// Production implementation of [`TypographySystem`].
92
pub struct RealSystem;
93
94
#[cfg_attr(coverage_nightly, coverage(off))]
95
impl TypographySystem for RealSystem {
96
    fn list_tracked_files(&self) -> Result<Vec<String>> {
97
        let output = std::process::Command::new("git")
98
            .args(["ls-files"])
99
            .output()
100
            .context("failed to run `git ls-files`")?;
101
        if !output.status.success() {
102
            bail!(
103
                "`git ls-files` exited non-zero: {}",
104
                String::from_utf8_lossy(&output.stderr)
105
            );
106
        }
107
        let stdout =
108
            String::from_utf8(output.stdout).context("`git ls-files` produced non-UTF-8 output")?;
109
        Ok(stdout
110
            .lines()
111
            .filter(|line| !line.is_empty())
112
            .map(|line| line.to_owned())
113
            .collect())
114
    }
115
116
    fn file_size(&self, path: &Path) -> Result<u64> {
117
        let meta = std::fs::metadata(path)
118
            .with_context(|| format!("failed to stat {}", path.display()))?;
119
        Ok(meta.len())
120
    }
121
122
    fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
123
        std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))
124
    }
125
}
126
127
/// A single offending code point found in a scanned file.
128
#[derive(Debug, Clone, PartialEq, Eq)]
129
pub struct Violation {
130
    /// Repository-relative path with forward slashes.
131
    pub path: String,
132
    /// 1-based line number of the offending character.
133
    pub line: u32,
134
    /// 1-based column (counted in `char`s, not bytes) of the offending
135
    /// character.
136
    pub column: u32,
137
    /// The offending Unicode scalar value.
138
    pub character: char,
139
}
140
141
/// Return `true` when `c` should be flagged by the scanner.
142
///
143
/// The blocklist is hand-curated to cover the decorative glyphs that
144
/// LLMs habitually substitute for ASCII punctuation. Emoji and other
145
/// non-ASCII characters are deliberately not included.
146
///
147
/// # Arguments
148
///
149
/// * `c` - Character to test.
150
///
151
/// # Returns
152
///
153
/// `true` when `c` is on the blocklist, `false` otherwise.
154
202
pub fn is_blocklisted(c: char) -> bool {
155
202
    let cp = c as u32;
156
186
    matches!(
157
202
        cp,
158
        // Non-breaking and middle-dot, multiplication, division.
159
        0x00A0 | 0x00B7 | 0x00D7 | 0x00F7
160
        // Exotic spaces.
161
18
        | 0x2000..=0x200B
162
        | 0x202F | 0x205F | 0x3000
163
        // Hyphens, en/em-dashes, horizontal bar, minus sign.
164
18
        | 0x2010..=0x2015 | 0x2212
165
        // Smart single and double quotes.
166
13
        | 0x2018..=0x201F
167
        // Bullet, ellipsis.
168
        | 0x2022 | 0x2026
169
        // Arrows block in its entirety.
170
9
        | 0x2190..=0x21FF
171
        // Math comparison glyphs.
172
        | 0x2248 | 0x2260 | 0x2264 | 0x2265
173
    )
174
202
}
175
176
/// Decide whether `path` should be scanned.
177
///
178
/// A file is scanned when:
179
///
180
/// 1. it is not in [`ALLOWED_PATHS`], and
181
/// 2. its lowercase extension is in [`SCAN_EXTENSIONS`], or its path
182
///    appears verbatim in [`SCAN_EXTRA_PATHS`].
183
///
184
/// # Arguments
185
///
186
/// * `path` - Forward-slash relative path as emitted by
187
///   `git ls-files`.
188
///
189
/// # Returns
190
///
191
/// `true` when the file should be scanned, `false` otherwise.
192
21
pub fn should_scan(path: &str) -> bool {
193
21
    if ALLOWED_PATHS.contains(&path) {
194
5
        return false;
195
16
    }
196
16
    if SCAN_EXTRA_PATHS.contains(&path) {
197
1
        return true;
198
15
    }
199
15
    let Some(
dot14
) = path.rfind('.') else {
200
1
        return false;
201
    };
202
14
    let ext = &path[dot + 1..];
203
14
    SCAN_EXTENSIONS
204
14
        .iter()
205
73
        .
any14
(|allowed| allowed.eq_ignore_ascii_case(ext))
206
21
}
207
208
/// Scan a single file's contents and return any violations.
209
///
210
/// Pure function -- no I/O. Files that are pure ASCII return early
211
/// before allocating or decoding UTF-8, which keeps the common case
212
/// (almost every `.rs` file in this repo) cheap.
213
///
214
/// Files that are not valid UTF-8 are reported via the returned
215
/// `non_utf8` flag and produce no violations; the caller decides
216
/// whether to surface that as a warning.
217
///
218
/// # Arguments
219
///
220
/// * `path` - Display path used when constructing violations.
221
/// * `bytes` - Raw file contents.
222
///
223
/// # Returns
224
///
225
/// `(violations, non_utf8)` where `non_utf8` is `true` if the file
226
/// could not be decoded as UTF-8.
227
10
pub fn scan_bytes(path: &str, bytes: &[u8]) -> (Vec<Violation>, bool) {
228
    // Fast path: pure ASCII -> nothing to flag.
229
112
    if 
bytes.iter()10
.
all10
(|&b| b < 0x80) {
230
3
        return (Vec::new(), false);
231
7
    }
232
233
7
    let Ok(
text5
) = std::str::from_utf8(bytes) else {
234
2
        return (Vec::new(), true);
235
    };
236
237
5
    let mut violations = Vec::new();
238
5
    let mut line: u32 = 1;
239
5
    let mut column: u32 = 1;
240
64
    for c in 
text5
.
chars5
() {
241
64
        if c == '\n' {
242
5
            line += 1;
243
5
            column = 1;
244
5
            continue;
245
59
        }
246
59
        if c == '\r' {
247
            // CRLF: do not advance the column. The following '\n' resets it.
248
0
            continue;
249
59
        }
250
59
        if is_blocklisted(c) {
251
5
            violations.push(Violation {
252
5
                path: path.to_owned(),
253
5
                line,
254
5
                column,
255
5
                character: c,
256
5
            });
257
54
        }
258
59
        column += 1;
259
    }
260
5
    (violations, false)
261
10
}
262
263
/// Scan every tracked text file and report violations.
264
///
265
/// Reads the file list via `git ls-files`, filters it through
266
/// [`should_scan`], and runs [`scan_bytes`] on each remaining file.
267
/// Violations are printed to stderr as
268
/// `path:line:col U+XXXX 'glyph'`.
269
///
270
/// # Arguments
271
///
272
/// * `system` - Injected I/O provider.
273
///
274
/// # Returns
275
///
276
/// `Ok(())` when no violations are found.
277
///
278
/// # Errors
279
///
280
/// Returns an error when at least one violation is found, or when an
281
/// I/O operation fails. Files that are too large or not valid UTF-8
282
/// are skipped with a warning and do not fail the run.
283
4
pub fn check_typography<S: TypographySystem>(system: &S) -> Result<()> {
284
4
    let files = system.list_tracked_files()
?0
;
285
4
    let mut violations: Vec<Violation> = Vec::new();
286
5
    for rel in 
files4
{
287
5
        if !should_scan(&rel) {
288
1
            continue;
289
4
        }
290
4
        let path = PathBuf::from(&rel);
291
4
        let size = system.file_size(&path)
?0
;
292
4
        if size > MAX_FILE_BYTES {
293
1
            log::warn!("skipping {rel}: {size} bytes exceeds {MAX_FILE_BYTES} byte cap");
294
1
            continue;
295
3
        }
296
3
        let bytes = system.read_file(&path)
?0
;
297
3
        let (mut found, non_utf8) = scan_bytes(&rel, &bytes);
298
3
        if non_utf8 {
299
1
            log::warn!("skipping {rel}: not valid UTF-8");
300
1
            continue;
301
2
        }
302
2
        violations.append(&mut found);
303
    }
304
305
4
    if violations.is_empty() {
306
3
        log::info!("check-typography: no forbidden Unicode found.");
307
3
        return Ok(());
308
1
    }
309
310
1
    let listing = violations
311
1
        .iter()
312
1
        .map(|v| {
313
1
            format!(
314
                "{}:{}:{} U+{:04X} {:?}",
315
1
                v.path, v.line, v.column, v.character as u32, v.character
316
            )
317
1
        })
318
1
        .collect::<Vec<_>>()
319
1
        .join("\n");
320
1
    log::error!(
321
        "check-typography: found {} forbidden Unicode character(s).\n\
322
         Replace them with their ASCII equivalents (em/en-dashes -> '-',\n\
323
         smart quotes -> ' or \", ellipsis -> ..., arrows -> -> / <-, etc.).\n\
324
         \n\
325
         {listing}",
326
1
        violations.len(),
327
    );
328
1
    bail!("found {} forbidden Unicode character(s)", violations.len())
329
4
}
330
331
#[cfg(test)]
332
#[path = "tests/test_typography.rs"]
333
mod tests;